为什么CCPP要分为头文件和源文件?

这是否和外部调用有关?为什么现在大多数语言都没有采用这种设计?为什么调用dll有时需要使用Windows提供的API导出函数或者结构,而不能直接include xxxx.h或者像C#写的dll那样在项目中添加引用然后直接using xxxx。

我试着从C/C++历史演变的角度回答下这个问题。

上世纪70年代初,C语言初始版本被设计出来时,是没有头文件的。这一点与后世的Java只有.java文件,C#只有.cs文件很相似。即使是现代的C编译器,头文件也不是必须的。我使用下面这个例子说明:

//alpha.c
int main(){
print_hello();
}
//beta.c
void print_hello(){
puts("hello");
}

上例只有两个源文件,alpha.c与beta.c。其中alpha.c使用了一个自定义函数print_hello,beta.c中使用了标准库函数puts。注意:alpha.c与beta.c都没有包含任何头文件。

你可以使用MSCL编译器来编译:

cl/Fe:program.exealpha.cbeta.c

或者GCC以及Clang:

clang-oprogramalpha.cbeta.c

这样会得到一个名为program的可执行文件,并且它可以正常工作。

以beta.c为例:当beta.c被编译时,编译器解析到名为puts的符号,虽然它是未定义的,但从语法上可以判断puts是一个函数,故而将其认定为函数,作为外部符号等待链接就可以了(倘若alpha,beta是C++源文件,编译无法通过,这个后文会做解释)。

下面我用ASCII字符绘制的“编译”与“链接”流程图:

alpha.c->alpha.obj
program.exe
/
beta.c->beta.obj

相信这个流程作为基础知识已广为人知,我就不再赘述了。问题在于:当初为什么要采用这样的设计?将“编译”、“链接”两个步骤区分开,并让用户可知是什么意图?

其实这是上世纪60、70年代各语言的“套路”做法,因为各个obj文件可能并不是同一种语言源文件编译得到的,它们可能来自于C,可能是汇编、也可能是Fortran这样与C一样的高级语言。即是说“编译”、“链接”的流程其实是这样的:

alpha.c->alpha.obj 
beta.asm->beta.obj-->program.exe
/
gamma.f->gamma.obj

所以,编译阶段C源文件(当然也包括其它语言的源文件)是不与其它源文件产生关系的,因为编译器(这里指的是狭义的编译器,不包括链接器)本身有可能并不能识别其它源。

说到这里,定然有人要问:连函数参数和返回值都不知道,直接链接然后调用,会不会出现问题。答案是:不会,至少当时不会。因为当时的C只有一种数据类型,即“字长”(同时代的大多数语言也一样)。

我们考虑这样一个函数调用:

n=add(1,2,3,4);

首先,add函数的调用者,将4个参数自右向左压入栈,即是说压栈完成后1在栈顶,4在栈底;然后,add被调用,对于被调用者(也就是add)而言,栈长度是不可知的,但第一个参数在栈顶,往下一个字长就是第二个参数,以此类推,所以栈长度不可知并不会带来问题;add处理完成后,将返回值放入数据寄存器,并返回;调用者弹栈,因为压栈操作是调用者实施的,故而栈长度、压栈前栈顶位置等信息调用者是可知的,可以调用者有能力保持栈平衡。

这里说一个题外话:倘若调用者压栈的参数不够,那会如何?答案是被调用者会在栈上读到垃圾数据;又问:倘若被调用者没有返回值,那会如何?答案是调用者会在寄存器得到垃圾数据;再问:如此在代码维护上不会有问题吗?答案是从后来的实践上看,问题不大,其实可以对比下如今python、lua等弱类型语言。

通过上面的论述,我们得知C语言设计之初是没有头文件的,调用某个函数也不需要提前声明。

不过好景不长,后来出现了不同的数据类型。例如出于可移植性和内存节省的考虑,出现了short int、long int;为了加强对块处理的IO设备的支持,出现了char。如此就带来了一个问题,即函数的调用者不知道压栈的长度。例如有函数调用:

add(x,y);

调用者知道add是一个函数,也知道需要将x、y压栈,但应该是先压2个字节、再压4个字节喃,还是先压4个字节,再压2个字节喃;还是连续压2个4字节喃?

这里需要说明一下,在上世纪80年代intel8084系的处理器普及以前,并没有公认的“字节(byte)”概念,以上只是我举例方便。

紧接着结构体等特性陆续引入,问题变得更复杂。在这种情况下,函数调用需要提前声明,以便让调用者得知函数的参数与返回值尺寸(结构体使用也需要提前声明,以便让调用者知道其成员、尺寸、内存对其规则等,这里不赘述了)。

于是,头文件就出现了。这里有人可能就会问了:为什么在编译一个源文件时,不去其它源文件查找声明,就如后世的Java、C#一样。主要原因上文已经说过:C源文件在编译时不与其它源产生关系,因为其它源可能根本就不是C;此外使用include将声明插入到源文件中,技术实现毕竟很简单,也可以说是一种技术惯性。

又后来出现了C++,由于函数重载、模板等特性,当编译器识别到一个函数,不仅是参数与返回值尺寸,连调用哪一个函数都无法从函数名辨别了(即上文的“倘若alpha,beta是C++源文件,编译无法通过,这个后文会做解释”一语)。函数与数据结构需要提前声明才能使用更是不可或缺。

本页共50段,2584个字符,6015 Byte(字节)